Skip to content

fix(thread-channel): honour the cancellation token on send and recv - #228

Merged
EdmondDantes merged 1 commit into
mainfrom
fix/thread-channel-cancellation-token
Aug 13, 2026
Merged

fix(thread-channel): honour the cancellation token on send and recv#228
EdmondDantes merged 1 commit into
mainfrom
fix/thread-channel-cancellation-token

Conversation

@EdmondDantes

@EdmondDantes EdmondDantes commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Stacked on #227.

ThreadChannel::send() and ThreadChannel::recv() both declare ?Async\Completable $cancellationToken and neither used it. recv() passed NULL into thread_channel_receive(), which has taken a cancellation event and handled it correctly all along; send() had no parameter to take one at all. So a parked call could be broken only by Coroutine::cancel(), and a bounded wait had to be hand-built from a cancel race — while Async\Channel honours the same argument on the same signature.

Measured before:

Channel::recv        threw OperationCanceledException after 300 ms
ThreadChannel::recv  STILL PARKED at 1000 ms
ThreadChannel::send  STILL PARKED at 1000 ms

After, all three report Async\OperationCanceledException at 301 ms.

What changed

thread_channel_send() becomes a wrapper over thread_channel_send_ex(channel, value, cancellation), which registers the event alongside the sender trigger. The vtable entry keeps its signature, so the pool's concurrency gate is untouched.

A wake is attributed to the token by asking the token, not by inspecting the buffer. One freed slot wakes every parked sender and one sent value wakes every parked receiver (fire_all_triggers), so on a channel with more than one parked caller the losers of that race see an unchanged buffer with the token unfired. Reading that as a cancellation returns false with nothing thrown, and RETURN_THROWS() then reports an exception that was never raised: ZEND_ASSERT(EG(exception)) aborts a debug build and a release build returns NULL from send()/recv(). The receive side had this branch already, dead — METHOD(recv) passed NULL and the pool's only other call is wait_only, which returns earlier — so wiring the token is what made it reachable. Both sides now require the event to be closed, and a wake that is neither the token nor an exception parks again.

A token that closed while an earlier round was parked is caught before re-registering: zend_async_resume_when() refuses a closed event and returns false, and suspending anyway would arm the channel trigger alone, turning a bounded wait into an unbounded one.

The userland methods resolve the token first — one that has already fired ends the call before it waits — and translate the false return through report_cancellation(). A timeout token raises its TimeoutException into EG(exception) rather than carrying it on the event, so async_resolve_cancel_token() cannot find it; it is held aside and chained onto the OperationCanceledException afterwards. Matching Channel matters here: Async\timeout() alone would surface TimeoutException, which extends \Exception rather than AsyncCancellation, so catch (AsyncCancellation) would work around one channel class and not the other.

Tests

tests/thread_channel/046-cancellation_token.phpt covers a cancelled recv, a cancelled send on a full channel, a token that never fires, and the chained previous exception.

tests/thread_channel/047-cancellation_token_spurious_wake.phpt covers the herd: two senders parked on a full channel and two receivers parked on an empty one, each woken by a single freed slot or a single value. It fails on the base branch with the assertion above, in both directions.

tests/thread_channel, tests/thread_pool, tests/channel and tests/thread — 278 tests, 0 failed (1 pre-existing warning: thread_pool/030 carries an --XFAIL-- section and passes). Full ext/async: 2109 tests, 3 failed, the same three pre-existing tests/curl and tests/io failures as on main.

@EdmondDantes
EdmondDantes force-pushed the fix/thread-channel-refused-value branch from 044cc23 to 58aff00 Compare August 13, 2026 16:44
@EdmondDantes
EdmondDantes force-pushed the fix/thread-channel-cancellation-token branch 2 times, most recently from 3de312a to 37f8bb9 Compare August 13, 2026 16:45
@EdmondDantes
EdmondDantes force-pushed the fix/thread-channel-refused-value branch from 58aff00 to 6cd7ab3 Compare August 13, 2026 18:02
@EdmondDantes
EdmondDantes force-pushed the fix/thread-channel-cancellation-token branch from 37f8bb9 to 4ab6e98 Compare August 13, 2026 18:02
@EdmondDantes
EdmondDantes deleted the branch main August 13, 2026 18:04
@EdmondDantes EdmondDantes reopened this Aug 13, 2026
@EdmondDantes
EdmondDantes changed the base branch from fix/thread-channel-refused-value to main August 13, 2026 18:07
@EdmondDantes
EdmondDantes merged commit 9eee540 into main Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant